<?php
/**
 * @copyright 2009-2019 Vanilla Forums Inc.
 * @license GPL-2.0-only
 */

use Garden\Schema\Schema;
use Garden\Web\Data;
use Garden\Web\Exception\ClientException;
use Garden\Web\Exception\ForbiddenException;
use Garden\Web\Exception\NotFoundException;
use Garden\Web\RequestInterface;
use Vanilla\ApiUtils;
use Vanilla\CurrentTimeStamp;
use Vanilla\Web\RoleTokenFactory;

/**
 * API Controller for the `/tokens` resource.
 */
class TokensApiController extends AbstractApiController
{
    /** Default expiry for issued tokens. */
    const DEFAULT_EXPIRY = "10 years";

    /** The maximum number of tokens in a response. */
    const RESPONSE_LIMIT = 200;

    /** Default token type. */
    const TOKEN_TYPE = "personal";

    /** @var AccessTokenModel */
    private $accessTokenModel;

    /** @var UserModel $userModel */
    private $userModel;

    /** @var RoleTokenFactory $roleTokenFactory */
    private $roleTokenFactory;

    /** @var Schema */
    private $fullSchema;

    /** @var Schema */
    private $sensitiveSchema;

    /**
     * TokensApiController constructor.
     *
     * @param AccessTokenModel $accessTokenModel
     * @param UserModel $userModel
     * @param RoleTokenFactory $roleTokenFactory
     */
    public function __construct(
        AccessTokenModel $accessTokenModel,
        UserModel $userModel,
        RoleTokenFactory $roleTokenFactory
    ) {
        $this->accessTokenModel = $accessTokenModel;
        $this->userModel = $userModel;
        $this->roleTokenFactory = $roleTokenFactory;
    }

    /**
     * Revoke an access token.
     *
     * @param int $id
     * @throws ClientException Throws an exception if current user isn't authorized to delete the token.
     */
    public function delete($id)
    {
        $this->idParamSchema()->setDescription("Revoke an access token.");
        $out = $this->schema([], "out");

        $row = $this->token($id);
        if ($row["UserID"] != $this->getSession()->UserID) {
            $this->permission("Garden.Settings.Manage");
        }

        $this->accessTokenModel->revoke($id);
    }

    /**
     * Given a token row, determine if it is active.
     *
     * @param int|string|array $token Full token row.
     * @param bool $throw Should relevant exceptions be thrown on an error?
     * @throws ClientException Throws an exception if the token has been revoked or has expired.
     * @return bool
     */
    public function isActiveToken($token, $throw = false)
    {
        if (is_array($token)) {
            $row = $token;
        } elseif (filter_var($token, FILTER_VALIDATE_INT)) {
            $row = $this->accessTokenModel->getID($token);
        } elseif (is_string($token)) {
            $row = $this->accessTokenModel->getToken($token);
        } else {
            $row = false;
        }

        if (!is_array($row)) {
            return false;
        }

        if (array_key_exists("Attributes", $row)) {
            $attributes = $row["Attributes"];
            if (is_array($attributes)) {
                // Skip if this token has been revoked.
                if (array_key_exists("revoked", $row["Attributes"]) && $row["Attributes"]["revoked"]) {
                    if ($throw) {
                        throw new ClientException("Token revoked.", 410);
                    }
                    return false;
                }
            }
        } else {
            return false;
        }

        // Skip if this token is expired.
        $expiry = strtotime($row["DateExpires"]);
        if (time() > $expiry) {
            if ($throw) {
                throw new ClientException("Token expired.", 410);
            }
            return false;
        }

        return true;
    }

    /**
     * Get a schema representing all available token fields.
     *
     * @return Schema
     */
    public function fullSchema()
    {
        if (!isset($this->fullSchema)) {
            $this->fullSchema = $this->schema(
                [
                    "accessTokenID:i" => "The unique numeric ID.",
                    "name:s|n" => "A user-specified label.",
                    "dateInserted:dt" => "When the token was generated.",
                    "dateLastUsed:dt?" => "Last time a token was used.",
                ],
                "Token"
            );
        }
        return $this->fullSchema;
    }

    /**
     * Get a signed copy of a token.
     *
     * @param int $id
     * @param array $query
     * @throws NotFoundException Throws an exception if this is not an active token.
     * @return array
     */
    public function get($id, array $query)
    {
        $this->permission("Garden.Tokens.Add");

        $this->idParamSchema();
        $in = $this->schema(
            ["id", "transientKey:s" => "A valid CSRF token for the current user."],
            "in"
        )->setDescription("Reveal a usable access token.");
        $out = $this->schema($this->sensitiveSchema(), "out");

        $query["id"] = $id;
        $query = $in->validate($query);
        $this->validateTransientKey($query["transientKey"]);

        $row = $this->token($id);
        if ($row["UserID"] != $this->getSession()->UserID) {
            if ($this->getSession()->checkPermission("Garden.Settings.Manage") === false) {
                throw new NotFoundException("Access Token");
            }
        }
        $this->isActiveToken($row, true);
        $row = $this->normalizeOutput($row);

        $result = $out->validate($row);
        return $result;
    }

    /**
     * Get an ID-only schema for token records.
     *
     * @return Schema
     */
    public function idParamSchema()
    {
        return $this->schema(["id:i" => "The numeric ID of a token."], "in");
    }

    /**
     * List active tokens for the current user.
     *
     * @return array
     * @throws ForbiddenException
     */
    public function index()
    {
        $this->permission("Garden.Tokens.Add");

        if ($this->getSession()->UserID == $this->userModel->getSystemUserID()) {
            throw new ForbiddenException("The System user is not allowed to see its existing tokens.");
        }

        $in = $this->schema([], "in")->setDescription("Get a list of access token IDs for the current user.");
        // Full access token details are not available in the index. Use GET on a single ID for sensitive information.
        $out = $this->schema(
            [
                ":a" => $this->schema(["accessTokenID", "name", "dateInserted", "dateLastUsed?"])->add(
                    $this->fullSchema()
                ),
            ],
            "out"
        );

        $rows = $this->accessTokenModel
            ->getWhere(
                [
                    "UserID" => $this->getSession()->UserID,
                    "Type" => self::TOKEN_TYPE,
                ],
                "DateInserted",
                "desc",
                self::RESPONSE_LIMIT
            )
            ->resultArray();
        $activeTokens = [];
        foreach ($rows as $token) {
            if ($this->isActiveToken($token) === false) {
                continue;
            }
            $activeTokens[] = $token;
        }
        unset($token);
        $activeTokens = array_map([$this, "normalizeOutput"], $activeTokens);

        $result = $out->validate($activeTokens);
        return $result;
    }

    /**
     * Issue a new access token for the current user.
     *
     * @param array $body
     * @return mixed
     * @throws ForbiddenException
     */
    public function post(array $body)
    {
        $this->permission("Garden.Tokens.Add");

        // System token should not be visible.
        if ($this->getSession()->UserID == $this->userModel->getSystemUserID()) {
            throw new ForbiddenException("The System user is not allowed to create access tokens.");
        }

        $in = $this->schema(
            [
                "name:s" => "A name indicating what the access token will be used for.",
                "transientKey:s" => "A valid CSRF token for the current user.",
            ],
            "in"
        )->setDescription("Issue a new access token for the current user.");
        $out = $this->schema($this->sensitiveSchema(), "out");

        $body = $in->validate($body);
        $this->validateTransientKey($body["transientKey"]);

        // Issue the new token.
        $accessToken = $this->accessTokenModel->issue(
            $this->getSession()->UserID,
            self::DEFAULT_EXPIRY,
            self::TOKEN_TYPE
        );
        $this->validateModel($this->accessTokenModel);
        $token = $this->accessTokenModel->trim($accessToken);
        $row = $this->accessTokenModel->getToken($token);
        $accessTokenID = $row["AccessTokenID"];
        $row = $this->accessTokenModel->setAttribute($accessTokenID, "name", $body["name"]);

        // Serve up the result.
        $row = $this->normalizeOutput($row);
        $result = $out->validate($row);
        return $result;
    }

    /**
     * Create a new time-scoped role token (JWT) based on the current user's roles
     *
     * @param RequestInterface $request Initiating request
     * @return array
     * @throws ForbiddenException Request initiated from guest user.
     * @throws \Garden\Schema\ValidationException Validation Failed.
     */
    public function post_roles(RequestInterface $request): array
    {
        if (!$this->getSession()->isValid()) {
            throw new ForbiddenException("Must be logged in to create a role token");
        }

        $in = $this->schema([], "in")->setDescription("Issue a role token for the current user");
        $out = $this->schema(
            [
                "roleToken:s" =>
                    "A signed JWT issued for the current user containing the set of roles assigned " .
                    "to this user in its claims",
                "expires:dt" => "DateTime when issued JWT expires",
            ],
            "out"
        );

        $roleIDs = $this->userModel->getRoleIDs($this->getSession()->UserID);
        $roleToken = $this->roleTokenFactory->forEncoding($roleIDs, $request);
        $response = [
            "roleToken" => $roleToken->encode(),
            "expires" => $roleToken->getExpires(),
        ];

        return $out->validate($response);
    }

    /**
     * Normalize a database record to match the Schema definition.
     *
     * @param array $dbRecord Database record.
     * @return array Return a Schema record.
     */
    public function normalizeOutput(array $dbRecord)
    {
        $name = null;
        if (array_key_exists("Attributes", $dbRecord) && is_array($dbRecord["Attributes"])) {
            if (array_key_exists("name", $dbRecord["Attributes"]) && is_string($dbRecord["Attributes"]["name"])) {
                $name = $dbRecord["Attributes"]["name"];
            }
        }
        $dbRecord["Name"] = $name ?: t("Personal Access Token");

        if (array_key_exists("Token", $dbRecord) && is_string($dbRecord["Token"])) {
            $dbRecord["AccessToken"] = $this->accessTokenModel->signTokenRow($dbRecord);
        }

        $schemaRecord = ApiUtils::convertOutputKeys($dbRecord);
        return $schemaRecord;
    }

    /**
     * Get a schema for outputting sensitive token information.
     */
    public function sensitiveSchema()
    {
        if (!isset($this->sensitiveSchema)) {
            $this->sensitiveSchema = $this->schema([
                "accessTokenID",
                "name",
                "accessToken:s" => "A signed version of the token.",
                "dateInserted",
            ])->add($this->fullSchema());
        }
        return $this->sensitiveSchema;
    }

    /**
     * Get an access token by its numeric ID.
     *
     * @param int $accessTokenID
     * @throws NotFoundException Throws an exception when the token cannot be located by its ID.
     * @return array
     */
    protected function token($accessTokenID)
    {
        $row = $this->accessTokenModel->getID($accessTokenID);
        if (!$row) {
            throw new NotFoundException("Access Token");
        }
        return $row;
    }

    /**
     * Validate the transient key for the current request.
     *
     * @param string $transientKey
     * @throws ClientException Throws an exception when the transient key is invalid.
     */
    public function validateTransientKey($transientKey)
    {
        if ($this->getSession()->transientKey() === false) {
            $this->getSession()->loadTransientKey();
        }

        if ($this->getSession()->transientKey() != $transientKey) {
            throw new ClientException("Invalid transient key.", 401);
        }
    }

    /**
     * Get the access token model dependency.
     *
     * @return AccessTokenModel
     */
    public function getAccessTokenModel(): AccessTokenModel
    {
        return $this->accessTokenModel;
    }
}
